Skip to content

resolver: pass absolute path to browser-map check for main field and index - #36620

Merged
Jarred-Sumner merged 4 commits into
mainfrom
farm/f93b1a59/browser-map-main-field
Aug 1, 2026
Merged

Jarred-Sumner merged 4 commits into
mainfrom
farm/f93b1a59/browser-map-main-field

Conversation

@robobun

@robobun robobun commented Aug 1, 2026 •

Copy link
Copy Markdown
Collaborator

What

When a package's "main" field and the matching "browser" map key are both written without an extension, bun build --target=browser ignores the browser remap and bundles the Node entrypoint.

mkdir -p node_modules/pkg/{lib,dist}
cat > node_modules/pkg/package.json <<'JSON'
{"name":"pkg","main":"./lib/index","browser":{"./lib/index":"./dist/pkg.min.js"}}
JSON
echo 'module.exports="LIB"' > node_modules/pkg/lib/index.js
echo 'module.exports="PREBUILT"' > node_modules/pkg/dist/pkg.min.js
echo 'console.log(require("pkg"))' > entry.js
bun build entry.js --target=browser   # bundles "LIB", should bundle "PREBUILT"

Real-world impact: import "jszip" with jszip@3.10.1, which ships exactly this pattern to point browsers at a prebuilt dist/jszip.min.js. Bun walks the full lib/ graph instead and ends up bundling node stream/buffer/events polyfills:

bytes
bun build before 470,373
bun build after 153,024
esbuild 158,095

Why

check_browser_map::<AbsolutePath> expects an absolute path so it can strip the package-directory prefix and look up the package-relative key. load_from_main_field and load_as_index_with_browser_remapping were passing the relative main-field value ("./lib/index", "index") instead, so the starts_with(abs_path) guard returned None and the whole remap branch in both functions was dead code. The only match that ever worked was the post-resolution check, which sees the resolved path with its extension and therefore only matches when the browser key also carries the extension.

esbuild computes fieldAbsPath := r.fs.Join(path, fieldRelPath) before calling checkBrowserMap here; this change brings both functions in line with that.

Making the remap branch reachable exposed two latent issues in the previously-dead code, also fixed here:

  • the disabled ("browser": {"./x": false}) arm stored a slice into a threadlocal scratch buffer directly in the returned MatchResult; it now interns via abs_alloc like the sibling in load_from_main_field;
  • the non-empty remap was joined against the subdirectory being indexed instead of the package that owns the browser map, so a subpath directory import (require('demo-pkg/sub') with "./sub/index.js": "./browser-sub.js" in demo-pkg/package.json) would have looked in demo-pkg/sub/browser-sub.js and failed. Both functions now join against browser_scope.abs_path.

Verification

The four main/browser extension permutations now match esbuild exactly:

main browser key esbuild bun before bun after
./lib/index.js ./lib/index.js remap remap remap
./lib/index ./lib/index.js remap remap remap
./lib/index.js ./lib/index no remap no remap no remap
./lib/index ./lib/index remap no remap remap

New itBundled tests in test/bundler/esbuild/packagejson.test.ts cover the jszip-shaped remap, the permutation matrix above, the false (disabled) case, the directory-main /index remap, and the subpath-directory remap. All fail on the released build and pass with this change; the rest of the file is unchanged (80 pass, 9 todo).

Not in this PR

The packagejson/BrowserNodeModulesNoExt todo test exercises bare subpath file imports (import 'demo-pkg/no-ext'). That needs a browser-map check inside load_node_modules before load_as_file_or_directory, which is missing entirely rather than mis-parameterised, and is left for a follow-up.

Fixes #22797
Fixes #21128

…index

check_browser_map<AbsolutePath> expects an absolute path that starts with
the package directory so it can strip that prefix and look up the
package-relative key in the browser map. load_from_main_field and
load_as_index_with_browser_remapping were passing the relative path
("./lib/index", "index") instead, so the starts_with guard rejected it
and the browser map was never consulted for main-field values.

This meant a package.json like

  "main": "./lib/index",
  "browser": {"./lib/index": "./dist/pkg.min.js"}

resolved to lib/index.js for browser targets instead of the prebuilt
bundle. jszip uses exactly this pattern and was pulling in the full
lib/ tree plus node polyfills, producing a 470KB bundle where esbuild
produces 165KB.

Compute the absolute path before the browser-map check, matching
esbuild's loadMainField / loadAsIndexWithBrowserRemapping.

Fixes #22797
Fixes #21128
@coderabbitai

coderabbitai Bot commented Aug 1, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 16 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 116582c2-b36b-4acc-8305-ba2f7c97f983

📥 Commits

Reviewing files that changed from the base of the PR and between 5f7e62d and 87fdac7.

📒 Files selected for processing (2)
  • src/resolver/resolver.rs
  • test/bundler/esbuild/packagejson.test.ts

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the claude label Aug 1, 2026
@robobun

robobun commented Aug 1, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 1:05 AM PT - Aug 1st, 2026

@robobun, your commit 87fdac7 is building: #86775

Comment thread src/resolver/resolver.rs
Comment thread src/resolver/resolver.rs
…t the browser scope

The browser-map check in load_as_index_with_browser_remapping was
previously dead code (the relative "index" never started with the
absolute package dir), so two latent issues in its remap branch never
fired:

- the disabled path was built with Path::init over a threadlocal scratch
  buffer, leaving the returned MatchResult pointing at memory the next
  resolve overwrites;
- the remap target was joined against the subdirectory being indexed
  instead of the package that owns the browser field, so a subpath
  directory import like require('demo-pkg/sub') with a remap in
  demo-pkg/package.json resolved to demo-pkg/sub/<remap> and failed.

Intern the disabled path via abs_alloc (matching the sibling in
load_from_main_field) and join the remap against browser_scope.abs_path
in both functions.
Comment thread src/resolver/resolver.rs Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both earlier findings (scratch-buffer intern for the disabled-index path, and joining the remap against browser_scope.abs_path) are correctly addressed in d8429ca, and the added BrowserMapSubpathDirIndex/BrowserMapSubpathDirIndexDisabled tests pin the newly-live branches. Deferring to a human only because this activates previously-dead resolver branches in a hot path — the change itself looks right.

What was reviewed:

  • Verified check_browser_map writes only bufs!(check_browser_map)/bufs!(abs_to_rel), so the new field_abs_path/index_abs_path borrows into bufs!(field_abs_path)/bufs!(remap_path) are not clobbered before use.
  • Confirmed the disabled branch at 5276 now uses abs_alloc (interned), matching the sibling at 5042.
  • Checked the six new itBundled cases cover the extension-permutation matrix, the false (disabled) case for both call sites, and the subpath-dir remap that would have regressed with the wrong join base.
Extended reasoning...

Overview

The PR fixes browser-map handling for the "main" field and implicit index when the browser-map key is written without an extension (jszip-shaped packages). Two functions in src/resolver/resolver.rs — load_from_main_field and load_as_index_with_browser_remapping — now compute an absolute path before calling check_browser_map::<AbsolutePath>, and resolve the remap value against browser_scope.abs_path instead of the local directory. Six itBundled tests are added to test/bundler/esbuild/packagejson.test.ts.

Prior findings addressed

My earlier pass flagged (1) the remap.is_empty() branch storing a slice into the threadlocal bufs!(remap_path) scratch directly in MatchResult.path_pair, and (2) the non-empty remap being joined against the subdirectory path rather than the browser-scope directory, which would have turned import 'demo-pkg/sub' into a hard resolve error. d8429ca fixes both: the disabled path is interned via abs_alloc(&index_paths), and both call sites now join against browser_scope.abs_path (matching esbuild's r.fs.Join(browserScope.absPath, *remapped)). New tests BrowserMapSubpathDirIndex and BrowserMapSubpathDirIndexDisabled cover exactly the case the second finding described.

Security risks

None. This is bundler/resolver path-selection logic; browser-map values are package-supplied and were already consumed elsewhere (the post-resolution check at 2135/2357). No new untrusted input surface.

Level of scrutiny

Moderate-to-high. resolver.rs is production-critical and threadlocal-scratch-heavy; the change activates two branches that were provably dead before (b"index" / field_rel_path never satisfied the starts_with(abs_path) guard). Both dead branches carried latent bugs that this PR then fixed after review. The diff is small (~30 lines net in resolver.rs) and now aligns line-for-line with the esbuild reference, but the fact that activating this code required two follow-up fixes argues for a maintainer familiar with the resolver's buffer discipline to sign off.

Other factors

I re-checked buffer aliasing after the fix: check_browser_map writes only bufs!(check_browser_map) and bufs!(abs_to_rel), so passing a slice of bufs!(field_abs_path) / bufs!(remap_path) into it is safe; the subsequent overwrite of bufs!(field_abs_path) at 5057 uses [browser_scope.abs_path, remap] as inputs, neither of which borrows that buffer. Test coverage is thorough (permutation matrix matches the esbuild table in the description; both disabled-path variants; the ancestor-scope subpath case). No outstanding reviewer comments.

@Jarred-Sumner
Jarred-Sumner merged commit 3761941 into main Aug 1, 2026
49 of 51 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/f93b1a59/browser-map-main-field branch August 1, 2026 06:53

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both earlier findings are addressed — the disabled-index path is now interned via abs_alloc, and both remap joins resolve against browser_scope.abs_path. I didn't find anything new this pass. That said, this activates two previously-dead resolver branches for browser targets; worth a human sign-off given the ecosystem-wide reach of resolution changes.

Checked: check_browser_map writes only bufs!(check_browser_map)/bufs!(abs_to_rel), so the new index_abs_path in bufs!(remap_path) isn't clobbered before use; the field_abs_path re-join reads only browser_scope.abs_path and remap (neither in field_abs_path's buffer); abs_alloc routes through DirnameStore so the disabled record outlives the frame. New tests cover the jszip shape, the 4-way ext matrix, disabled, dir-main index, and the subpath-dir remap that would have regressed with the wrong join base.

Extended reasoning...

Overview

Fixes #22797 / #21128: load_from_main_field and load_as_index_with_browser_remapping were passing a relative path to check_browser_map::<AbsolutePath>, whose first line is if !input_path.starts_with(dir_info.abs_path) { return None } — so the entire remap branch in both functions was dead code and packages like jszip@3.10.1 ("main": "./lib/index", "browser": {"./lib/index": "./dist/jszip.min.js"}) bundled the Node graph instead of the prebuilt browser file. The PR computes the absolute path first (matching esbuild's fieldAbsPath := r.fs.Join(path, fieldRelPath)) and passes that. Making the branch live exposed two latent bugs in the previously-dead code, which I flagged in the first pass and which were fixed in d8429ca: (1) the disabled arm stored a scratch-buffer slice into MatchResult — now interned via abs_alloc, matching the sibling at 5042; (2) the non-empty remap was joined against the subdirectory being indexed instead of browser_scope.abs_path — now fixed in both functions, with a new BrowserMapSubpathDirIndex test covering the case that would have hard-errored.

Security risks

None. This is module-resolution logic for the "browser" field in package.json; no auth/crypto/permissions surface. Inputs come from on-disk package.json files already trusted for resolution.

Level of scrutiny

High. The resolver is production-critical and subtle; a wrong remap here changes which file gets bundled for every browser build that hits the pattern. This PR specifically activates two code paths that have never executed before, which is exactly where latent bugs hide (and did — two were found and fixed during review). The change is small and tracks the esbuild reference closely, but resolver behavior changes deserve a maintainer's eyes.

Other factors

  • Six new itBundled tests in packagejson.test.ts cover the reported bug, the full main×browser extension permutation matrix (verified against esbuild in the PR description), the false disabled case, the directory-main /index case, and the subpath-directory remap (both remap-to-file and disabled). The subpath test directly exercises the join-base fix.
  • I re-checked buffer aliasing: check_browser_map writes bufs!(check_browser_map)/bufs!(abs_to_rel), distinct from the new bufs!(remap_path)/bufs!(field_abs_path) writes; the re-join at 5057 reads only interned browser_scope.abs_path and the browser-map-owned remap, not the buffer being overwritten; abs_alloc uses join_abs_string's separate threadlocal, so index_paths (which borrows remap_path_trailing_slash) is not clobbered.
  • The .expect("unreachable") on abs_alloc matches the existing sibling at line 5042.
  • The one CI failure (test/regression/issue/36577.test.ts on Windows x64) is unrelated to this change.
  • All three prior inline threads (my two bug reports + the comment-cop nit) are resolved.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bundle size much bigger than esbuild bun build --target=browser does not work with JSZip

2 participants